04 - 多租户与配额:两条完全不同的路
数据快照 2026-08-19。代码引自当天拉取的
BerriAI/litellm@main与envoyproxy/ai-gateway@main。
模型账单只有一张,但用它的有十个团队。"这次请求算谁头上、还剩多少额度、超了怎么办" —— 这是网关从"路由器"变成"平台"的那一步。
LiteLLM 和 Envoy AI Gateway 在这件事上给出了两个完全不同的答案,而且分歧的根源不是偏好,是形态(见 01 - 四种形态)。
前置:01 - 网关是什么 里的虚拟密钥概念。
本篇回答:十个团队共用一张模型账单时,"还剩多少额度、这次算谁头上"是怎么被算准的。
会用到的词:
- TOCTOU(Time-Of-Check to Time-Of-Use):先检查再使用,中间被别人插了一脚 —— 限流被击穿的经典原因
- Redis Lua 脚本:Redis 单线程执行 Lua,所以一段脚本里的多个操作天然原子,是实现分布式限流最常见的手段
- descriptor(描述符):限流的对象标识,比如"研究团队这把密钥"或"gpt-4o 这个模型",一次请求可以同时携带多个
- Redis Cluster / hash tag / CROSSSLOT:Redis 集群把 key 分散到不同分片,一次操作碰不到跨分片的多个 key(报 CROSSSLOT),用花括号
{}标记的部分相同则保证落在同一分片
一、LiteLLM:把限流写成 Redis Lua 脚本
litellm/proxy/hooks/parallel_request_limiter_v3.py 有 4,789 行,是整个 proxy 里最硬核的一个文件。它的核心不是 Python,是嵌在里面 的两段 Redis Lua 脚本。
为什么必须是 Lua
限流的经典问题是 TOCTOU(check-then-act):读计数 → 判断 → 写计数,这三步之间别的副本插进来了,限额就被击穿。多副本部署下这不是理论问题,是必然发生的。
LiteLLM 的解法是把整个"检查并递增"塞进一个 Lua 脚本,靠 Redis 单线程执行保证原子性。脚本头部的注释把契约写得非常完整:
-- Atomic check-and-increment-by-N across one or more descriptors.
-- All-or-nothing: if any descriptor would exceed its limit, no counter is
-- modified.
--
-- Uses Redis server time (`redis.call('TIME')`) instead of a client-supplied
-- timestamp so that window resets are deterministic across replicas with
-- skewed wall-clocks. This prevents a clock-skew-induced reopening of the
-- TOCTOU window across multi-replica deployments.
--
-- KEYS layout: pairs of (window_key, counter_key), one pair per descriptor.
-- ARGV layout: per-descriptor 4-tuple, starting at ARGV[1]:
-- ARGV[(i-1)*4 + 1] = limit
-- ARGV[(i-1)*4 + 2] = increment
-- ARGV[(i-1)*4 + 3] = ttl_seconds (counter TTL when window resets)
-- ARGV[(i-1)*4 + 4] = window_size_seconds (sliding-window length)
--
-- Return on success:
-- { 0, new_counter_1, window_start_1, new_counter_2, window_start_2, ... }
-- Return on over-limit: { 1, descriptor_index, current_counter, limit }
三个设计点值得单独拎出来:
1. 用 redis.call('TIME') 而不是客户端时间戳。
local time_reply = redis.call('TIME')
local now = tonumber(time_reply[1])
如果时间由 Python 端传进来,多个副本的机器时钟哪怕只差几百毫秒,窗口重置就会在不同副本上发生在不同时刻 —— 等于重新打开了刚被 Lua 关上的 TOCTOU 窗口。注释里明确说了这是在防 "clock-skew-induced reopening"。这是那种只有真被线上打穿过才写得出来的注释。
2. 两趟扫描,全有或全无。
-- Pass 1: read state, validate. Abort without writing if any over limit.
local descriptor_state = {}
for i = 1, descriptor_count do
...
end
第一趟只读不写,任何一个描述符超限就整体中止;第二趟才真正递增。这样"key 的额度够但 team 的额度不够"时,不会出现 key 的计数被扣了而请求被拒的错账。
3. Redis Cluster 的 hash tag。
window_key = f"{{{descriptor_key}:{descriptor_value}}}:window"
那三层大括号在 Python f-string 里最终渲染成 {descriptor_key:descriptor_value}:window —— 花括号是 Redis Cluster 的 hash tag 语法,保证同一个描述符的 window 和 counter 落在同一个 slot 上。源码注释解释了后果:
Cluster-safety: each descriptor's keys all share a `{key:value}` hash
tag, so the Redis Lua path issues one Lua call per descriptor — every
... CROSSSLOT errors. Cross-descriptor atomicity is preserved via
refund-on-rollback: if descriptor i is OVER_LIMIT, descriptors 0..i-1
这里有个诚实的妥协:在 Redis Cluster 下,跨描述符的原子性做不到了(不同描述符在不同 slot,一次 Lua 调用碰不到),于是降级成"退款回滚"——先扣,发现后面的超限了再把前面扣的还回去。单实例 Redis 下是真原子,Cluster 下是补偿事务。这个区别在文档里不会写,只在源码注释里。
描述符:多租户的实际载体
限流的对象叫 descriptor,每个描述符是一个 (key, value) 对,带三种限额:
rpm_key = self.create_rate_limit_keys(descriptor_key, descriptor_value, "requests")
tpm_key = self.create_rate_limit_keys(descriptor_key, descriptor_value, "tokens")
# 以及 max_parallel_requests(走独立路径,不进 Lua 窗口)
一次请求会同时携带多个描述符 —— 虚拟密钥、用户、团队、终端用户、模型,每一层都有自己的额度。全有或全无的语义就是为这个服务的。
这套东西的完整形态就是"虚拟密钥":key_management_endpoints.py(280 KB)负责发放和管理这些密钥,user_api_key_auth.py(139 KB)负责在请求入口把密钥翻译成一组描述符,auth_checks.py(5,172 行)负责层层校验。
二、Envoy AI Gateway:把配额翻译成 Envoy 原生描述符
Envoy AI Gateway 完全不自己实现限流。它做的事是:把 K8s 里的 QuotaPolicy CRD 翻译成 Envoy 的 RateLimit 配置,然后交给 Envoy 的限流过滤器和外部限流服务去执行。
const (
quotaRateLimitClusterName = "ai_gateway_ratelimit_cluster"
quotaRateLimitFilterName = "envoy.filters.http.ratelimit/ai-gateway-quota"
defaultQuotaRateLimitServicePort = 8081
// quotaCostMetadataKey is the dynamic metadata key where ext_proc stores
// the computed quota cost for the current request.
quotaCostMetadataKey = "quota_cost"
)
注意过滤器名字带了 /ai-gateway-quota 后缀,注释解释了原因:要和 Envoy Gateway 自带的限流过滤器区分开,否则两套限流会互相覆盖。这是在别人生态里搭房子必须处理的细节。
关键机制:LLM 的成本在请求开始时是未知的
传统限流按"请求数"计费,一次请求就是一次。但 LLM 的配额要按 token 算,而 token 数在请求发出时根本不知道,输出 token 更是要等流式响应结束才知道。
Envoy 的解法是 HitsAddend —— 让一次请求按 N 计数,N 从动态元数据里读:
// quotaHitsAddend returns the HitsAddend that reads the quota cost from dynamic
// metadata stored by the ext_proc filter.
func quotaHitsAddend() *routev3.RateLimit_HitsAddend {
return &routev3.RateLimit_HitsAddend{
Format: fmt.Sprintf("%%DYNAMIC_METADATA(%s:%s)%%",
aigv1b1.AIGatewayFilterMetadataNamespace, quotaCostMetadataKey),
}
}
完整链路是这样的:
源码里对应的就是 request-time 和 stream-done 两套描述符条目:
// Request-time entries only. Stream-done is added once per model in enableQuotaRateLimitOnRoute.
先按估算扣一次,流结束后按实际再补一次。 这是把"成本未知"这个 LLM 特有的问题塞进 Envoy 既有限流模型的唯一办法。
描述符从哪来:动态元数据
func baseDescriptorActions() []*routev3.RateLimit_Action {
return []*routev3.RateLimit_Action{
{ActionSpecifier: &routev3.RateLimit_Action_Metadata{
Metadata: &routev3.RateLimit_Action_MetaData{
DescriptorKey: translator.BackendNameDescriptorKey,
MetadataKey: &metadatav3.MetadataKey{
Key: aigv1b1.AIGatewayFilterMetadataNamespace,
Path: []*metadatav3.MetadataKey_PathSegment{{
Segment: &metadatav3.MetadataKey_PathSegment_Key{
Key: "ai_service_backend_name",
}}},
},
Source: routev3.RateLimit_Action_MetaData_DYNAMIC,
}}},
// 第二个:model_name_override
}
}
两级描述符:backend_name + model_name_override。都来自 DYNAMIC 元数据,也就是 ext_proc 在处理请求时写进去的 —— 因为"这次请求实际会打到哪个模型"要解析请求体才知道,路由配置阶段填不出来。
三、两条路的对比
| LiteLLM | Envoy AI Gateway | |
|---|---|---|
| 限流在哪执行 | 自己实现,Redis Lua | Envoy 限流过滤器 + 外部限流服务 |
| 配额怎么配 | API / 数据库里的虚拟密钥 | K8s QuotaPolicy CRD |
| 原子性 | 单实例真原子;Cluster 下退款回滚 | 交给限流服务保证 |
| token 成本未知怎么办 | 请求前后各更新一次计数 | request-time + stream-done 两套描述符 |
| 想改限流算法 | 改 Lua 脚本 | 换限流服务实现 |
| 依赖 | Redis | Kubernetes + Envoy + 限流服务 |
LiteLLM 是"我全都自己做",Envoy AI Gateway 是"我只做翻译"。
前者的代价是那 4,789 行里藏着的所有分布式细节都得自己维护 —— 时钟偏移、hash tag、退款回滚,每一条都是可能出错的地方。后者的代价是你必须先有一整套 Envoy + K8s + 限流服务的基础设施,而且遇到问题要在三个组件之间来回定位。
做过多租户平台的人会认出这个取舍:它就是"自研中间件"和"用云厂商托管服务"那个老问题在 AI 网关上的复现。我在 RAG Agent Platform 里选的是第一条路,代价是租户隔离的每个边界条件都得自己想清楚。
四、一个两家都没解决好的问题
流式请求的配额是滞后的。
不管哪种实现,输出 token 的真实数量都要等流结束才知道。这中间的窗口里:
- 用户可以同时发起 100 个流式请求,每个都通过了 request-time 检查
- 等它们陆续结束,配额已经超了几十倍
- 而且这些 token 已经真实产生了费用,追不回来
Envoy 的 stream-done 描述符能把账记准,但记准不等于拦住。LiteLLM 的 max_parallel_requests 走独立路径不进 Lua 窗口,某种程度上是在补这个洞 —— 限制并发数,间接限制了滞后窗口内能溜进来的量。
真要解决,只能在流式响应过程中持续计费并中途掐断,代价是要改动响应处理的热路径。两家目前都没做。 这是 2026 年 AI 网关领域一个公开的未解问题。
下一篇 → 04 - MCP 网关:一个客户端连多个 MCP 后端,会话、工具列表、通知流怎么合并。